SPB Git

spb/airiskindex Public

The most methodologically rigorous, fully transparent AI job-exposure index.

TypeScript 88% Python 6.1% SQL 2.7% CSS 1.2% JavaScript 0.9% Shell 0.8%
21.1 KB · 501 lines tsx
Raw Blame History
1//  File:    page.tsx2//  Path:    apps/web/app/occupations/[code]/page.tsx3//  Project: AI Risk Index — airiskindex.io4//  Author:  Simon-Pierre Boucher5//  Contact: contact@spboucher.ai6//  Copyright © 2026 Simon-Pierre Boucher. All rights reserved.7//8//  Description: Occupation detail: sub-score tiles, dimension breakdown, per-task rater audit trail.910import Link from "next/link";11import { notFound } from "next/navigation";12import { prisma } from "@airiskindex/db";13import {14  DIMENSIONS,15  HIGH_EXPOSURE_THRESHOLD,16  INVERTED_DIMENSIONS,17  WEIGHTS,18  pressure,19  type DimensionKey,20} from "@airiskindex/scoring";21import {22  DistributionChart,23  ScoreBar,24  ShareMeter,25  SubScoreDotPlot,26} from "@/components/score-marks";27import { formatWage, socGroupName } from "@/lib/soc-groups";2829export const dynamic = "force-dynamic";3031const DIMENSION_LABELS: Record<DimensionKey | "augmentation", string> = {32  automatability: "Task automatability",33  feasibility: "Technical feasibility today",34  cost_ratio: "Cost vs. human wage",35  barriers: "Adoption barriers",36  adoption_velocity: "Sector adoption velocity",37  augmentation: "Augmentation potential",38};3940interface DimensionAggregate {41  dimension: DimensionKey;42  weight: number;43  inverted: boolean;44  pressure: number; // 0–100, orientation applied45  meanRating: number; // raw 1–5 panel mean46}4748async function loadOccupation(code: string) {49  const occupation = await prisma.occupation.findUnique({50    where: { code },51    include: { tasks: { orderBy: { importance: "desc" } } },52  });53  if (!occupation) return null;5455  const score = await prisma.occupationScore.findFirst({56    where: { occupationCode: code },57    orderBy: { run: { createdAt: "desc" } },58    include: { run: true },59  });6061  const taskIds = occupation.tasks.map((task) => task.id);62  const [taskScores, ratings] = await Promise.all([63    score64      ? prisma.taskScore.findMany({ where: { runId: score.runId, taskId: { in: taskIds } } })65      : Promise.resolve([]),66    prisma.taskRating.findMany({67      where: { taskId: { in: taskIds } },68      select: { taskId: true, dimension: true, model: true, rating: true, rationale: true },69      orderBy: [{ dimension: "asc" }, { model: "asc" }],70    }),71  ]);7273  let rank: number | null = null;74  let scoredTotal: number | null = null;75  if (score) {76    [rank, scoredTotal] = await Promise.all([77      prisma.occupationScore.count({78        where: { runId: score.runId, substitution: { gt: score.substitution } },79      }),80      prisma.occupationScore.count({ where: { runId: score.runId } }),81    ]);82    rank += 1;83  }8485  const [related, indexScores] = await Promise.all([86    score87      ? prisma.occupationScore.findMany({88          where: {89            runId: score.runId,90            occupationCode: { startsWith: code.slice(0, 2), not: code },91          },92          orderBy: { substitution: "desc" },93          take: 5,94          include: { occupation: { select: { code: true, title: true } } },95        })96      : Promise.resolve([]),97    score98      ? prisma.occupationScore.findMany({99          where: { runId: score.runId },100          select: { substitution: true },101        })102      : Promise.resolve([]),103  ]);104105  return { occupation, score, taskScores, ratings, rank, scoredTotal, related, indexScores };106}107108function toBins(values: number[], binCount = 20): number[] {109  const bins = Array.from({ length: binCount }, () => 0);110  for (const value of values) {111    bins[Math.min(binCount - 1, Math.floor((value / 100) * binCount))] += 1;112  }113  return bins;114}115116function aggregateDimensions(117  tasks: Array<{ id: string; importance: number | null }>,118  ratings: Array<{ taskId: string; dimension: string; rating: number }>,119): DimensionAggregate[] {120  const out: DimensionAggregate[] = [];121  for (const dimension of DIMENSIONS) {122    let weightSum = 0;123    let ratingSum = 0;124    for (const task of tasks) {125      const values = ratings126        .filter((row) => row.taskId === task.id && row.dimension === dimension)127        .map((row) => row.rating);128      if (values.length === 0) continue;129      const mean = values.reduce((a, b) => a + b, 0) / values.length;130      const weight = task.importance ?? 3;131      ratingSum += mean * weight;132      weightSum += weight;133    }134    if (weightSum === 0) continue;135    const meanRating = ratingSum / weightSum;136    out.push({137      dimension,138      weight: WEIGHTS[dimension],139      inverted: INVERTED_DIMENSIONS.has(dimension),140      pressure: 100 * pressure(dimension, meanRating),141      meanRating,142    });143  }144  return out;145}146147export default async function OccupationPage({148  params,149}: {150  params: { code: string };151}): Promise<JSX.Element> {152  const data = await loadOccupation(params.code);153  if (!data) notFound();154  const { occupation, score, taskScores, ratings, rank, scoredTotal, related, indexScores } = data;155156  const byTask = new Map(taskScores.map((entry) => [entry.taskId, entry]));157  const ratingsByTask = new Map<string, typeof ratings>();158  for (const row of ratings) {159    const list = ratingsByTask.get(row.taskId) ?? [];160    list.push(row);161    ratingsByTask.set(row.taskId, list);162  }163  const rankedTasks = [...occupation.tasks].sort(164    (a, b) => (byTask.get(b.id)?.substitution ?? -1) - (byTask.get(a.id)?.substitution ?? -1),165  );166  const dimensions = aggregateDimensions(occupation.tasks, ratings);167  const wage = formatWage(occupation.medianWageCents);168169  return (170    <main className="mx-auto max-w-4xl px-4 py-10 sm:px-6 sm:py-12">171      <nav className="text-sm text-[var(--ink-2)]">172        <Link href="/#ranking" className="hover:text-[var(--ink)]">173          Ranking174        </Link>{" "}175        /{" "}176        <Link177          href={`/occupations#g${occupation.code.slice(0, 2)}`}178          className="hover:text-[var(--ink)]"179        >180          {socGroupName(occupation.code)}181        </Link>182      </nav>183184      <div className="mt-4 flex flex-wrap items-baseline gap-x-3 gap-y-1">185        <h1 className="text-3xl font-bold tracking-tight">{occupation.title}</h1>186        <span className="rounded-full border border-[var(--border)] px-2.5 py-0.5 text-xs text-[var(--muted)]">187          {occupation.code}188        </span>189      </div>190      <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-[var(--ink-2)]">191        {wage && <span>Median wage {wage}</span>}192        {occupation.employment != null && (193          <span>{occupation.employment.toLocaleString("en-US")} employed (US)</span>194        )}195        {rank != null && scoredTotal != null && (196          <span>197            Rank <strong className="text-[var(--ink)]">#{rank}</strong> of {scoredTotal} scored ·198            top {Math.max(1, Math.round((rank / scoredTotal) * 100))}% by substitution199          </span>200        )}201      </div>202      {occupation.description && (203        <p className="mt-3 max-w-2xl leading-relaxed text-[var(--ink-2)]">204          {occupation.description}205        </p>206      )}207208      {!score ? (209        <div className="mt-10 card p-8 text-sm text-[var(--ink-2)]">210          This occupation has not been scored yet — its {occupation.tasks.length} tasks are queued211          for the multi-model rater panel. Task statements are listed below.212          <ul className="mt-4 list-inside list-disc space-y-1">213            {occupation.tasks.slice(0, 20).map((task) => (214              <li key={task.id}>{task.statement}</li>215            ))}216          </ul>217        </div>218      ) : (219        <>220          <section className="mt-10">221            <div className="card p-5 sm:p-6">222              <div className="flex flex-wrap items-baseline justify-between gap-2">223                <h2 className="text-lg font-semibold tracking-tight">Sub-scores</h2>224                <p className="text-xs text-[var(--muted)]">225                  0–100 · band = confidence interval from rater disagreement226                </p>227              </div>228              <div className="mt-4">229                <SubScoreDotPlot230                  rows={[231                    {232                      label: "Substitution",233                      band: {234                        low: score.substitutionLow,235                        score: score.substitution,236                        high: score.substitutionHigh,237                      },238                    },239                    {240                      label: "Exposure",241                      band: {242                        low: score.exposureLow,243                        score: score.exposure,244                        high: score.exposureHigh,245                      },246                    },247                    {248                      label: "Augmentation",249                      band: {250                        low: score.augmentationLow,251                        score: score.augmentation,252                        high: score.augmentationHigh,253                      },254                    },255                  ]}256                />257              </div>258              <div className="mt-5 grid gap-4 border-t border-[var(--grid)] pt-4 text-xs leading-relaxed text-[var(--muted)] sm:grid-cols-3">259                <p>260                  <strong className="font-medium text-[var(--ink-2)]">Substitution</strong> — the261                  headline: capability discounted by cost, barriers and adoption.262                </p>263                <p>264                  <strong className="font-medium text-[var(--ink-2)]">Exposure</strong> —265                  technical capability alone, regardless of whether anyone deploys it.266                </p>267                <p>268                  <strong className="font-medium text-[var(--ink-2)]">Augmentation</strong> — how269                  much AI assists without replacing. High here + moderate substitution = a270                  changing job, not a disappearing one.271                </p>272              </div>273            </div>274            <div className="mt-4 grid gap-4 lg:grid-cols-2">275              <div className="card min-w-0 p-5">276                <h3 className="text-sm font-semibold">Tasks on the substitution scale</h3>277                <p className="mt-0.5 text-xs text-[var(--muted)]">278                  {taskScores.length} rated tasks, binned by substitution score.279                </p>280                <div className="mt-4">281                  <DistributionChart bins={toBins(taskScores.map((t) => t.substitution))} />282                </div>283              </div>284              <div className="card min-w-0 p-5">285                <h3 className="text-sm font-semibold">Position among all scored occupations</h3>286                <p className="mt-0.5 text-xs text-[var(--muted)]">287                  Distribution of {indexScores.length.toLocaleString("en-US")} occupation scores;288                  the marker is this occupation.289                </p>290                <div className="mt-4">291                  <DistributionChart292                    bins={toBins(indexScores.map((s) => s.substitution))}293                    marker={score.substitution}294                    markerLabel={rank != null ? `#${rank} · ${score.substitution.toFixed(0)}` : score.substitution.toFixed(0)}295                  />296                </div>297              </div>298            </div>299            <div className="mt-4">300              <ShareMeter301                share={score.highlyExposedTaskShare}302                label={`Tasks with substitution ≥ ${HIGH_EXPOSURE_THRESHOLD}`}303              />304            </div>305            <p className="mt-3 text-xs text-[var(--muted)]">306              Run {score.run.indexVersion} · computed {score.run.createdAt.toISOString().slice(0, 10)} ·307              rater panel: {score.run.raterModels.join(", ")} · intervals span rater disagreement.308            </p>309          </section>310311          {dimensions.length > 0 && (312            <section className="mt-12">313              <h2 className="text-xl font-semibold tracking-tight">Why this score</h2>314              <p className="mt-1 max-w-2xl text-sm text-[var(--ink-2)]">315                The five weighted dimensions of the composite, averaged across this occupation's316                tasks (importance-weighted, panel mean). Exact weights and formulas:{" "}317                <Link href="/api/v1/methodology" className="underline">318                  /api/v1/methodology319                </Link>320                .321              </p>322              <div className="mt-5 overflow-hidden card">323                {dimensions.map((dim) => (324                  <div key={dim.dimension} className="border-b border-[var(--grid)] px-5 py-3.5 last:border-b-0">325                    <div className="flex flex-wrap items-baseline gap-2">326                      <span className="text-sm font-medium">327                        {DIMENSION_LABELS[dim.dimension]}328                      </span>329                      <span className="rounded-full bg-[var(--seq-track)] px-2 py-0.5 text-[10px] font-semibold tabular-nums">330                        w {(dim.weight * 100).toFixed(0)}%331                      </span>332                      {dim.inverted && (333                        <span className="text-xs text-[var(--muted)]">334                          inverted — strong barriers lower the score335                        </span>336                      )}337                      <span className="ml-auto text-sm font-semibold tabular-nums">338                        {dim.pressure.toFixed(0)}339                      </span>340                    </div>341                    <div aria-hidden="true" className="mt-1.5 h-[6px] rounded-r-[3px] bg-[var(--seq-track)]">342                      <div343                        className="h-full rounded-r-[3px] bg-[var(--seq)]"344                        style={{ width: `${Math.min(100, dim.pressure)}%` }}345                      />346                    </div>347                    <p className="mt-1 text-xs text-[var(--muted)]">348                      panel mean rating {dim.meanRating.toFixed(1)}/5349                      {dim.inverted350                        ? " (barrier strength) → substitution pressure "351                        : " → substitution pressure "}352                      {dim.pressure.toFixed(0)}/100353                    </p>354                  </div>355                ))}356              </div>357            </section>358          )}359360          <section className="mt-12">361            <h2 className="text-xl font-semibold tracking-tight">362              Task breakdown{" "}363              <span className="text-sm font-normal text-[var(--muted)]">364                ({rankedTasks.length} tasks)365              </span>366            </h2>367            <p className="mt-1 text-sm text-[var(--ink-2)]">368              Substitution pressure per task, weighted by O*NET importance in the composite.369              Expand a task for the full rater audit trail — every rating, every model, every370              rationale.371            </p>372            <div className="mt-5 overflow-hidden card">373              {rankedTasks.map((task) => {374                const ts = byTask.get(task.id);375                const taskRatings = ratingsByTask.get(task.id) ?? [];376                return (377                  <details key={task.id} className="group border-b border-[var(--grid)] last:border-b-0">378                    <summary className="cursor-pointer list-none px-5 py-4 hover:bg-[var(--wash)]">379                      <div className="flex items-baseline gap-3">380                        <p className="text-sm">{task.statement}</p>381                        <span className="ml-auto shrink-0 pl-3 text-sm font-semibold tabular-nums">382                          {ts ? ts.substitution.toFixed(0) : "—"}383                        </span>384                      </div>385                      <div className="mt-2">386                        {ts ? (387                          <ScoreBar388                            band={{389                              low: ts.substitutionLow,390                              score: ts.substitution,391                              high: ts.substitutionHigh,392                            }}393                            thick={8}394                          />395                        ) : (396                          <div className="h-[8px] rounded-[4px] bg-[var(--seq-track)]" />397                        )}398                      </div>399                      {ts && (400                        <p className="mt-1.5 text-xs text-[var(--muted)]">401                          CI {ts.substitutionLow.toFixed(0)}–{ts.substitutionHigh.toFixed(0)} ·402                          exposure {ts.exposure.toFixed(0)} · augmentation{" "}403                          {ts.augmentation.toFixed(0)}404                          {task.importance != null && <> · importance {task.importance.toFixed(1)}/5</>}405                          {taskRatings.length > 0 && <> · click for rater detail</>}406                        </p>407                      )}408                    </summary>409                    {taskRatings.length > 0 && (410                      <div className="overflow-x-auto border-t border-[var(--grid)] bg-[var(--page)] px-5 py-4">411                        <table className="w-full min-w-[480px] text-left text-xs">412                          <caption className="sr-only">413                            Panel ratings per dimension for this task414                          </caption>415                          <thead className="text-[var(--muted)]">416                            <tr>417                              <th className="py-1 pr-3 font-medium">Dimension</th>418                              <th className="py-1 pr-3 font-medium">Model</th>419                              <th className="py-1 pr-3 font-medium">Rating</th>420                              <th className="py-1 font-medium">Rationale</th>421                            </tr>422                          </thead>423                          <tbody className="align-top">424                            {taskRatings.map((row, index) => (425                              <tr426                                key={`${row.dimension}-${row.model}-${index}`}427                                className="border-t border-[var(--grid)]"428                              >429                                <td className="py-1.5 pr-3 whitespace-nowrap">430                                  {DIMENSION_LABELS[row.dimension as DimensionKey] ?? row.dimension}431                                </td>432                                <td className="py-1.5 pr-3 whitespace-nowrap text-[var(--muted)]">433                                  {row.model}434                                </td>435                                <td className="py-1.5 pr-3 font-semibold tabular-nums">436                                  {row.rating}/5437                                </td>438                                <td className="py-1.5 text-[var(--ink-2)]">{row.rationale}</td>439                              </tr>440                            ))}441                          </tbody>442                        </table>443                      </div>444                    )}445                  </details>446                );447              })}448            </div>449          </section>450451          {related.length > 0 && (452            <section className="mt-12">453              <h2 className="text-xl font-semibold tracking-tight">454                Related occupations — {socGroupName(occupation.code)}455              </h2>456              <ul className="mt-4 overflow-hidden card">457                {related.map((row) => (458                  <li key={row.occupationCode} className="border-b border-[var(--grid)] last:border-b-0">459                    <Link460                      href={`/occupations/${row.occupationCode}`}461                      className="flex items-baseline gap-3 px-5 py-3 hover:bg-[var(--wash)]"462                    >463                      <span className="min-w-0 truncate text-sm">{row.occupation.title}</span>464                      <span className="ml-auto text-sm font-semibold tabular-nums">465                        {row.substitution.toFixed(0)}466                      </span>467                    </Link>468                  </li>469                ))}470              </ul>471            </section>472          )}473474          <section className="mt-12 grid gap-4 sm:grid-cols-2">475            <div className="card p-6">476              <h2 className="font-semibold">How to read this</h2>477              <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]">478                A high substitution score does not mean this job disappears — it means a large479                share of its current tasks face replacement pressure, so the mix of tasks is480                likely to change. High augmentation alongside substitution typically means the481                occupation reorganizes around the protected tasks. Wide confidence intervals mean482                the rater panel disagreed: treat those scores as open questions, not verdicts.483              </p>484            </div>485            <div className="card p-6">486              <h2 className="font-semibold">What would change this score</h2>487              <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]">488                New model capabilities (automatability, feasibility), falling inference costs489                (cost ratio), regulation and licensing shifts (barriers), and measured sector490                adoption (velocity) all re-enter at every index release. Each release is491                recomputed, versioned and kept queryable — scores are claims with a date on them,492                not permanent labels.493              </p>494            </div>495          </section>496        </>497      )}498    </main>499  );500}501